在 Day 21 我們建立了 Vitest 和 React Testing Library 的測試環境。今天我們要實作元件測試的進階技巧和測試驅動開發 (TDD) 的實踐,透過實際案例學習如何開發高品質的前端測試。
/**
* TDD 的紅綠重構循環
*
* 🔴 Red: 先寫一個會失敗的測試
* - 定義預期行為
* - 確保測試真的會失敗
*
* 🟢 Green: 寫最少的程式碼讓測試通過
* - 只要能通過測試即可
* - 先求有,再求好
*
* 🔵 Refactor: 重構程式碼但保持測試通過
* - 改善程式碼品質
* - 消除重複
* - 優化設計
*
* 重複這個循環,直到功能完成
*/
讓我們用 TDD 的方式從零開始開發一個租戶切換元件:
// src/components/TenantSwitcher/TenantSwitcher.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { TenantSwitcher } from './TenantSwitcher';
describe('TenantSwitcher', () => {
// 🔴 紅: 先寫測試
it('should display current tenant name', () => {
const currentTenant = {
id: '1',
name: 'Gym A',
slug: 'gym-a',
};
render(<TenantSwitcher current={currentTenant} tenants={[]} />);
// 期望看到當前租戶名稱
expect(screen.getByText('Gym A')).toBeInTheDocument();
});
});
執行測試,應該會失敗 (因為元件還不存在)。現在寫最簡單的實作:
// src/components/TenantSwitcher/TenantSwitcher.tsx
// 🟢 綠: 最簡單的實作
interface Tenant {
id: string;
name: string;
slug: string;
}
interface TenantSwitcherProps {
current: Tenant;
tenants: Tenant[];
}
export function TenantSwitcher({ current }: TenantSwitcherProps) {
return <div>{current.name}</div>;
}
測試通過! 但程式碼還很陽春,繼續下一個測試:
// TenantSwitcher.test.tsx
it('should show tenant list when clicked', async () => {
const user = userEvent.setup();
const currentTenant = {
id: '1',
name: 'Gym A',
slug: 'gym-a',
};
const tenants = [
currentTenant,
{ id: '2', name: 'Gym B', slug: 'gym-b' },
{ id: '3', name: 'Gym C', slug: 'gym-c' },
];
render(<TenantSwitcher current={currentTenant} tenants={tenants} />);
// 點擊當前租戶
await user.click(screen.getByText('Gym A'));
// 應該顯示所有租戶
expect(screen.getByText('Gym B')).toBeInTheDocument();
expect(screen.getByText('Gym C')).toBeInTheDocument();
});
現在讓測試通過:
// TenantSwitcher.tsx
import { useState } from 'react';
import { Menu, Button } from '@mantine/core';
export function TenantSwitcher({ current, tenants }: TenantSwitcherProps) {
const [opened, setOpened] = useState(false);
return (
<Menu opened={opened} onChange={setOpened}>
<Menu.Target>
<Button onClick={() => setOpened(!opened)}>
{current.name}
</Button>
</Menu.Target>
<Menu.Dropdown>
{tenants.map(tenant => (
<Menu.Item key={tenant.id}>
{tenant.name}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
}
// TenantSwitcher.test.tsx
it('should call onSwitch when tenant is selected', async () => {
const user = userEvent.setup();
const onSwitch = vi.fn();
const currentTenant = {
id: '1',
name: 'Gym A',
slug: 'gym-a',
};
const tenants = [
currentTenant,
{ id: '2', name: 'Gym B', slug: 'gym-b' },
];
render(
<TenantSwitcher
current={currentTenant}
tenants={tenants}
onSwitch={onSwitch}
/>
);
// 開啟選單
await user.click(screen.getByText('Gym A'));
// 選擇 Gym B
await user.click(screen.getByText('Gym B'));
// 應該呼叫 onSwitch
expect(onSwitch).toHaveBeenCalledWith({
id: '2',
name: 'Gym B',
slug: 'gym-b',
});
});
實作:
// TenantSwitcher.tsx
interface TenantSwitcherProps {
current: Tenant;
tenants: Tenant[];
onSwitch?: (tenant: Tenant) => void;
}
export function TenantSwitcher({ current, tenants, onSwitch }: TenantSwitcherProps) {
const [opened, setOpened] = useState(false);
const handleSelect = (tenant: Tenant) => {
setOpened(false);
onSwitch?.(tenant);
};
return (
<Menu opened={opened} onChange={setOpened}>
<Menu.Target>
<Button onClick={() => setOpened(!opened)}>
{current.name}
</Button>
</Menu.Target>
<Menu.Dropdown>
{tenants.map(tenant => (
<Menu.Item
key={tenant.id}
onClick={() => handleSelect(tenant)}
>
{tenant.name}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
}
// TenantSwitcher.test.tsx
it('should highlight current tenant in the list', async () => {
const user = userEvent.setup();
const currentTenant = {
id: '1',
name: 'Gym A',
slug: 'gym-a',
};
const tenants = [
currentTenant,
{ id: '2', name: 'Gym B', slug: 'gym-b' },
];
render(<TenantSwitcher current={currentTenant} tenants={tenants} />);
await user.click(screen.getByText('Gym A'));
// 當前租戶應該有特殊標記
const currentItem = screen.getByRole('menuitem', { name: /Gym A/i });
expect(currentItem).toHaveAttribute('data-active', 'true');
});
it('should show tenant switch loading state', () => {
const currentTenant = {
id: '1',
name: 'Gym A',
slug: 'gym-a',
};
render(
<TenantSwitcher
current={currentTenant}
tenants={[]}
isLoading={true}
/>
);
expect(screen.getByRole('button')).toHaveAttribute('data-loading', 'true');
});
完整實作:
// TenantSwitcher.tsx
import { useState } from 'react';
import { Menu, Button, Loader } from '@mantine/core';
import { IconBuilding, IconCheck } from '@tabler/icons-react';
interface Tenant {
id: string;
name: string;
slug: string;
}
interface TenantSwitcherProps {
current: Tenant;
tenants: Tenant[];
onSwitch?: (tenant: Tenant) => void;
isLoading?: boolean;
}
export function TenantSwitcher({
current,
tenants,
onSwitch,
isLoading = false,
}: TenantSwitcherProps) {
const [opened, setOpened] = useState(false);
const handleSelect = (tenant: Tenant) => {
if (tenant.id === current.id) return;
setOpened(false);
onSwitch?.(tenant);
};
return (
<Menu opened={opened} onChange={setOpened} width={260}>
<Menu.Target>
<Button
leftSection={<IconBuilding size={16} />}
rightSection={isLoading ? <Loader size="xs" /> : null}
variant="subtle"
data-loading={isLoading}
>
{current.name}
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>切換租戶</Menu.Label>
{tenants.map(tenant => {
const isCurrent = tenant.id === current.id;
return (
<Menu.Item
key={tenant.id}
onClick={() => handleSelect(tenant)}
data-active={isCurrent}
rightSection={isCurrent ? <IconCheck size={16} /> : null}
style={{
backgroundColor: isCurrent
? 'var(--mantine-color-blue-light)'
: undefined,
}}
>
{tenant.name}
</Menu.Item>
);
})}
</Menu.Dropdown>
</Menu>
);
}
// src/components/CourseScheduler/CourseScheduler.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { CourseScheduler } from './CourseScheduler';
describe('CourseScheduler - Async Operations', () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
});
it('should show loading state while fetching courses', async () => {
// Mock API 延遲回應
const mockFetch = vi.fn(() =>
new Promise(resolve =>
setTimeout(() => resolve({ data: [] }), 100)
)
);
render(
<QueryClientProvider client={queryClient}>
<CourseScheduler fetchCourses={mockFetch} />
</QueryClientProvider>
);
// 應該立即顯示 loading
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
// 等待資料載入完成
await waitFor(() => {
expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument();
});
expect(mockFetch).toHaveBeenCalledOnce();
});
it('should handle API errors gracefully', async () => {
const mockFetch = vi.fn(() =>
Promise.reject(new Error('Network error'))
);
render(
<QueryClientProvider client={queryClient}>
<CourseScheduler fetchCourses={mockFetch} />
</QueryClientProvider>
);
// 等待錯誤訊息出現
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
expect(screen.getByText(/network error/i)).toBeInTheDocument();
});
it('should refetch data when retry button is clicked', async () => {
const user = userEvent.setup();
const mockFetch = vi
.fn()
.mockRejectedValueOnce(new Error('Failed'))
.mockResolvedValueOnce({ data: [{ id: 1, name: 'Course A' }] });
render(
<QueryClientProvider client={queryClient}>
<CourseScheduler fetchCourses={mockFetch} />
</QueryClientProvider>
);
// 等待錯誤出現
await waitFor(() => {
expect(screen.getByText(/failed/i)).toBeInTheDocument();
});
// 點擊重試按鈕
const retryButton = screen.getByRole('button', { name: /retry/i });
await user.click(retryButton);
// 等待成功載入
await waitFor(() => {
expect(screen.getByText('Course A')).toBeInTheDocument();
});
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
// src/components/Members/MemberForm.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { MemberForm } from './MemberForm';
describe('MemberForm - Complex Interactions', () => {
it('should validate form before submission', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<MemberForm onSubmit={onSubmit} />);
// 不填寫任何欄位直接送出
const submitButton = screen.getByRole('button', { name: /submit/i });
await user.click(submitButton);
// 應該顯示驗證錯誤
expect(await screen.findByText(/name is required/i)).toBeInTheDocument();
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
// 不應該呼叫 onSubmit
expect(onSubmit).not.toHaveBeenCalled();
});
it('should validate email format in real-time', async () => {
const user = userEvent.setup();
render(<MemberForm />);
const emailInput = screen.getByLabelText(/email/i);
// 輸入無效的 email
await user.type(emailInput, 'invalid-email');
await user.tab(); // 觸發 blur 事件
// 應該顯示格式錯誤
expect(
await screen.findByText(/invalid email format/i)
).toBeInTheDocument();
// 修正為有效的 email
await user.clear(emailInput);
await user.type(emailInput, 'user@example.com');
await user.tab();
// 錯誤訊息應該消失
await waitFor(() => {
expect(
screen.queryByText(/invalid email format/i)
).not.toBeInTheDocument();
});
});
it('should handle multi-step form navigation', async () => {
const user = userEvent.setup();
render(<MemberForm mode="create" steps={['basic', 'membership', 'payment']} />);
// Step 1: 基本資訊
expect(screen.getByText(/step 1/i)).toBeInTheDocument();
await user.type(screen.getByLabelText(/name/i), 'John Doe');
await user.type(screen.getByLabelText(/email/i), 'john@example.com');
// 下一步
await user.click(screen.getByRole('button', { name: /next/i }));
// Step 2: 會員方案
await waitFor(() => {
expect(screen.getByText(/step 2/i)).toBeInTheDocument();
});
await user.click(screen.getByLabelText(/premium plan/i));
// 返回上一步
await user.click(screen.getByRole('button', { name: /back/i }));
// 應該回到 Step 1,且資料保留
expect(screen.getByLabelText(/name/i)).toHaveValue('John Doe');
// 再次前進
await user.click(screen.getByRole('button', { name: /next/i }));
await user.click(screen.getByRole('button', { name: /next/i }));
// Step 3: 付款資訊
expect(screen.getByText(/step 3/i)).toBeInTheDocument();
});
});
// src/stores/auth.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useAuthStore } from './auth';
describe('AuthStore', () => {
beforeEach(() => {
// 重置 store 狀態
useAuthStore.setState({
user: null,
token: null,
isAuthenticated: false,
});
});
it('should initialize with unauthenticated state', () => {
const { result } = renderHook(() => useAuthStore());
expect(result.current.user).toBeNull();
expect(result.current.token).toBeNull();
expect(result.current.isAuthenticated).toBe(false);
});
it('should set user and token on login', () => {
const { result } = renderHook(() => useAuthStore());
const user = {
id: '1',
name: 'John Doe',
email: 'john@example.com',
};
const token = 'mock-jwt-token';
act(() => {
result.current.login(user, token);
});
expect(result.current.user).toEqual(user);
expect(result.current.token).toBe(token);
expect(result.current.isAuthenticated).toBe(true);
});
it('should clear state on logout', () => {
const { result } = renderHook(() => useAuthStore());
// 先登入
act(() => {
result.current.login(
{ id: '1', name: 'John', email: 'john@test.com' },
'token'
);
});
expect(result.current.isAuthenticated).toBe(true);
// 登出
act(() => {
result.current.logout();
});
expect(result.current.user).toBeNull();
expect(result.current.token).toBeNull();
expect(result.current.isAuthenticated).toBe(false);
});
it('should persist token to localStorage on login', () => {
const { result } = renderHook(() => useAuthStore());
const token = 'persistent-token';
act(() => {
result.current.login(
{ id: '1', name: 'Jane', email: 'jane@test.com' },
token
);
});
expect(localStorage.getItem('auth-token')).toBe(token);
});
it('should remove token from localStorage on logout', () => {
const { result } = renderHook(() => useAuthStore());
// 先設置 token
localStorage.setItem('auth-token', 'some-token');
act(() => {
result.current.logout();
});
expect(localStorage.getItem('auth-token')).toBeNull();
});
});
// src/hooks/useAuth.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useAuth } from './useAuth';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
describe('useAuth Hook', () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
it('should return current user when authenticated', async () => {
// Mock API
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
user: { id: '1', name: 'Test User' },
}),
})
) as any;
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.user).toEqual({
id: '1',
name: 'Test User',
});
expect(result.current.isLoading).toBe(false);
});
});
it('should handle login mutation', async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
token: 'new-token',
user: { id: '2', name: 'Logged In User' },
}),
})
) as any;
const { result } = renderHook(() => useAuth(), { wrapper });
// 執行登入
act(() => {
result.current.login({
email: 'user@test.com',
password: 'password',
});
});
await waitFor(() => {
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.user?.name).toBe('Logged In User');
});
});
});
// ❌ 避免過度使用 data-testid
<div data-testid="user-profile">
<h1 data-testid="user-name">{user.name}</h1>
<p data-testid="user-email">{user.email}</p>
</div>
// ✅ 優先使用語義化查詢
<div role="region" aria-label="User Profile">
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
// 測試:
expect(screen.getByRole('region', { name: /user profile/i })).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(user.name);
// ❌ 過度 mock
vi.mock('@mantine/core', () => ({
Button: ({ children, onClick }: any) => (
<button onClick={onClick}>{children}</button>
),
Menu: ({ children }: any) => <div>{children}</div>,
// ... mock 所有元件
}));
// ✅ 只 mock 必要的外部依賴
vi.mock('./api/client', () => ({
fetchCourses: vi.fn(),
createCourse: vi.fn(),
}));
// 保持 UI 元件使用真實實作
// ❌ 不清楚的測試描述
it('works', () => {
// ...
});
it('test case 1', () => {
// ...
});
// ✅ 清楚描述行為
it('should display error message when email format is invalid', () => {
// ...
});
it('should call onSubmit with form data when all validations pass', () => {
// ...
});
# 執行測試並生成覆蓋率報告
pnpm test -- --coverage
# 覆蓋率報告範例:
# ┌────────────────────┬───────┬────────┬─────────┬─────────┐
# │ File │ % Stmts│ % Branch│ % Funcs │ % Lines │
# ├────────────────────┼───────┼────────┼─────────┼─────────┤
# │ TenantSwitcher.tsx │ 95.45│ 88.89 │ 100.00 │ 95.23 │
# │ MemberForm.tsx │ 87.50│ 81.25 │ 90.00 │ 86.96 │
# │ useAuth.ts │ 92.31│ 85.71 │ 88.89 │ 91.67 │
# └────────────────────┴───────┴────────┴─────────┴─────────┘
我們今天實作了前端測試的進階技巧: